Create a batch service plugin The batch plugin provides a mechanism for building and previewing sets of proposed updates to multiple project/branches/refs that should be applied in a batch. These updates are built with Gerrit changes. This initial change provides a mechanism to merge a change to a batch with a single command. Change-Id: I860fa82dfa6cfe2029c534c55593e580c6f8afc6
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef8bd39 --- /dev/null +++ b/.gitignore
@@ -0,0 +1,11 @@ +/target +/.bazel_path +/.classpath +/.settings +/.project +/.buckd +/.DS_Store +/bazel-* +/buck-cache +/buck-out +*.iml
diff --git a/BUILD b/BUILD new file mode 100644 index 0000000..52f23b5 --- /dev/null +++ b/BUILD
@@ -0,0 +1,18 @@ +load("//tools/bzl:plugin.bzl", "gerrit_plugin") + +gerrit_plugin( + name = "batch", + srcs = glob(["src/main/java/**/*.java"]), + manifest_entries = [ + "Gerrit-PluginName: batch", + "Gerrit-ApiVersion: 2.15", + "Implementation-Title: Batch Plugin", + "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/batch", + "Gerrit-Module: com.googlesource.gerrit.plugins.batch.Module", + "Gerrit-SshModule: com.googlesource.gerrit.plugins.batch.ssh.SshModule", + ], + resources = glob(["src/main/resources/**/*"]), + deps = [ + "//gerrit-util-cli:cli" + ], +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11069ed --- /dev/null +++ b/LICENSE
@@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.
diff --git a/WORKSPACE b/WORKSPACE new file mode 100644 index 0000000..cf1b021 --- /dev/null +++ b/WORKSPACE
@@ -0,0 +1,26 @@ +workspace(name = "batch") + +load("//:bazlets.bzl", "load_bazlets") + +load_bazlets( + commit = "1affa0acc6e730f8959c28a2098b562d11a90f91", + # local_path = "/home/<user>/projects/bazlets", +) + +# Release Plugin API +load( + "@com_googlesource_gerrit_bazlets//:gerrit_api.bzl", + "gerrit_api", +) + +# Snapshot Plugin API +#load( +# "@com_googlesource_gerrit_bazlets//:gerrit_api_maven_local.bzl", +# "gerrit_api_maven_local", +#) + +# Load release Plugin API +gerrit_api() + +# Load snapshot Plugin API +#gerrit_api_maven_local() diff --git a/bazlets.bzl b/bazlets.bzl new file mode 100644 index 0000000..e14e488 --- /dev/null +++ b/bazlets.bzl
@@ -0,0 +1,17 @@ +NAME = "com_googlesource_gerrit_bazlets" + +def load_bazlets( + commit, + local_path = None + ): + if not local_path: + native.git_repository( + name = NAME, + remote = "https://gerrit.googlesource.com/bazlets", + commit = commit, + ) + else: + native.local_repository( + name = NAME, + path = local_path, + )
diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4c7f0e4 --- /dev/null +++ b/pom.xml
@@ -0,0 +1,99 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +Copyright (C) 2018 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <groupId>com.googlesource.gerrit.plugins.batch</groupId> + <artifactId>batch</artifactId> + <packaging>jar</packaging> + <version>2.15</version> + <name>batch</name> + + <properties> + <Gerrit-ApiType>plugin</Gerrit-ApiType> + <Gerrit-ApiVersion>${project.version}</Gerrit-ApiVersion> + </properties> + + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jar-plugin</artifactId> + <version>2.4</version> + <configuration> + <archive> + <manifestEntries> + <Gerrit-PluginName>Batch</Gerrit-PluginName> + <Gerrit-Module>com.googlesource.gerrit.plugins.batch.Module</Gerrit-Module> + <Gerrit-SshModule>com.googlesource.gerrit.plugins.batch.ssh.SshModule</Gerrit-SshModule> + + <Implementation-Vendor>Gerrit Code Review</Implementation-Vendor> + <Implementation-URL>http://code.google.com/p/gerrit/</Implementation-URL> + + <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title> + <Implementation-Version>${project.version}</Implementation-Version> + + <Gerrit-ApiType>${Gerrit-ApiType}</Gerrit-ApiType> + <Gerrit-ApiVersion>${Gerrit-ApiVersion}</Gerrit-ApiVersion> + </manifestEntries> + </archive> + </configuration> + </plugin> + + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>2.3.2</version> + <configuration> + <source>1.8</source> + <target>1.8</target> + <encoding>UTF-8</encoding> + </configuration> + </plugin> + </plugins> + </build> + + <dependencies> + <dependency> + <groupId>com.google.gerrit</groupId> + <artifactId>gerrit-${Gerrit-ApiType}-api</artifactId> + <version>${Gerrit-ApiVersion}</version> + <scope>provided</scope> + </dependency> + + <dependency> + <groupId>org.eclipse.jgit</groupId> + <artifactId>org.eclipse.jgit</artifactId> + <version>3.4.1.201406201815-r</version> + </dependency> + + <dependency> + <groupId>org.apache.sshd</groupId> + <artifactId>sshd-core</artifactId> + <version>0.14.0</version> + </dependency> + </dependencies> + + <repositories> + <repository> + <id>gerrit-api-repository</id> + <url>https://gerrit-api.commondatastorage.googleapis.com/release/</url> + </repository> + </repositories> +</project> \ No newline at end of file diff --git a/src/main/java/com/google/gerrit/reviewdb/client/File.java b/src/main/java/com/google/gerrit/reviewdb/client/File.java new file mode 100644 index 0000000..78e2f84 --- /dev/null +++ b/src/main/java/com/google/gerrit/reviewdb/client/File.java
@@ -0,0 +1,51 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.reviewdb.client; + +import com.google.gwtorm.client.StringKey; + +public class File { + /** An immutable reference to a file in gerrit repo. */ + public static class NameKey extends StringKey<Branch.NameKey> { + private static final long serialVersionUID = 1L; + + protected Branch.NameKey branch; + protected String fileName; + + protected NameKey() { + branch = new Branch.NameKey(new Project.NameKey(null), null); + } + + public NameKey(Branch.NameKey br, String file) { + branch = br; + fileName = file; + } + + @Override + public String get() { + return fileName; + } + + @Override + protected void set(String file) { + fileName = file; + } + + @Override + public Branch.NameKey getParentKey() { + return branch; + } + } +} diff --git a/src/main/java/com/google/gerrit/server/git/meta/GitFile.java b/src/main/java/com/google/gerrit/server/git/meta/GitFile.java new file mode 100644 index 0000000..0ff7aa1 --- /dev/null +++ b/src/main/java/com/google/gerrit/server/git/meta/GitFile.java
@@ -0,0 +1,97 @@ +// Copyright (C) 2015 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.git.meta; + +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.File; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.git.MetaDataUpdate; +import com.google.gerrit.server.git.VersionedMetaData; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import java.io.IOException; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; + +/** A GitFile is a text file (UTF8) from a git repository */ +public class GitFile extends VersionedMetaData { + public interface Factory { + GitFile create(@Assisted File.NameKey file); + } + + protected final MetaDataUpdate.User metaDataUpdateFactory; + protected final GitRepositoryManager repos; + + protected Branch.NameKey branch; + protected String file; + + public String text; + + @Inject + public GitFile( + MetaDataUpdate.User metaDataUpdateFactory, + GitRepositoryManager repos, + @Assisted File.NameKey file) { + this.metaDataUpdateFactory = metaDataUpdateFactory; + this.repos = repos; + this.branch = file.getParentKey(); + this.file = file.get(); + } + + public String read() throws ConfigInvalidException, IOException, NoSuchProjectException { + try (Repository repo = repos.openRepository(branch.getParentKey())) { + load(repo); + return text; + } + } + + public RevCommit write(String fileContent, String commitMessage) + throws ConfigInvalidException, IOException, NoSuchProjectException { + try (MetaDataUpdate md = metaDataUpdateFactory.create(branch.getParentKey())) { + load(md); + text = fileContent; + md.getCommitBuilder().setCommitter(metaDataUpdateFactory.getUserPersonIdent()); + md.setMessage(commitMessage); + return commit(md); + } + } + + public void setBranch(Branch.NameKey branch) { + this.branch = branch; + } + + public void setFileName(String fileName) { + this.file = fileName; + } + + @Override + protected String getRefName() { + return branch.get(); + } + + @Override + protected void onLoad() throws IOException { + text = readUTF8(file); + } + + @Override + protected boolean onSave(CommitBuilder commit) throws IOException { + saveUTF8(file, text); + return true; + } +} diff --git a/src/main/java/com/google/gerrit/server/util/RefUpdater.java b/src/main/java/com/google/gerrit/server/util/RefUpdater.java new file mode 100644 index 0000000..3c74202 --- /dev/null +++ b/src/main/java/com/google/gerrit/server/util/RefUpdater.java
@@ -0,0 +1,201 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.gerrit.server.util; + +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.GerritPersonIdent; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.account.AccountState; +import com.google.gerrit.server.extensions.events.GitReferenceUpdated; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.git.TagCache; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.inject.Inject; +import com.google.inject.Provider; +import java.io.IOException; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RefUpdater { + private static final Logger log = LoggerFactory.getLogger(RefUpdater.class); + + public class Args { + public final Branch.NameKey branch; + public ObjectId expectedOldObjectId; + public ObjectId newObjectId; + public boolean isForceUpdate; + public PersonIdent refLogIdent; + public String refLogMessage; + + public Args(Branch.NameKey branch) { + this.branch = branch; + CurrentUser user = userProvider.get(); + if (user instanceof IdentifiedUser) { + refLogIdent = ((IdentifiedUser) user).newRefLogIdent(); + } else { + refLogIdent = gerrit; + } + } + } + + protected final Provider<CurrentUser> userProvider; + protected final @GerritPersonIdent PersonIdent gerrit; + protected final GitRepositoryManager repoManager; + protected final GitReferenceUpdated gitRefUpdated; + protected final TagCache tagCache; + protected final AccountCache accountCache; + + @Inject + RefUpdater( + AccountCache accountCache, + Provider<CurrentUser> userProvider, + @GerritPersonIdent PersonIdent gerrit, + GitRepositoryManager repoManager, + TagCache tagCache, + GitReferenceUpdated gitRefUpdated) { + this.accountCache = accountCache; + this.userProvider = userProvider; + this.gerrit = gerrit; + this.repoManager = repoManager; + this.tagCache = tagCache; + this.gitRefUpdated = gitRefUpdated; + } + + public void update(Branch.NameKey branch, ObjectId oldRefId, ObjectId newRefId) + throws IOException, NoSuchProjectException { + this.update(branch, oldRefId, newRefId, null); + } + + public void update( + Branch.NameKey branch, ObjectId oldRefId, ObjectId newRefId, String refLogMessage) + throws IOException, NoSuchProjectException { + Args args = new Args(branch); + args.expectedOldObjectId = oldRefId; + args.newObjectId = newRefId; + args.refLogMessage = refLogMessage; + this.update(args); + } + + public void forceUpdate(Branch.NameKey branch, ObjectId newRefId) + throws IOException, NoSuchProjectException { + this.forceUpdate(branch, newRefId, null); + } + + public void forceUpdate(Branch.NameKey branch, ObjectId newRefId, String refLogMessage) + throws IOException, NoSuchProjectException { + Args args = new Args(branch); + args.newObjectId = newRefId; + args.isForceUpdate = true; + args.refLogMessage = refLogMessage; + update(args); + } + + public void delete(Branch.NameKey branch) throws IOException, NoSuchProjectException { + Args args = new Args(branch); + args.newObjectId = ObjectId.zeroId(); + args.isForceUpdate = true; + update(args); + } + + public void update(Args args) throws IOException, NoSuchProjectException { + new Update(args).update(); + } + + protected class Update { + protected Repository repo; + protected Args args; + protected RefUpdate update; + protected Branch.NameKey branch; + protected Project.NameKey project; + protected boolean delete; + + protected Update(Args args) throws IOException { + this.args = args; + branch = args.branch; + project = branch.getParentKey(); + delete = args.newObjectId.equals(ObjectId.zeroId()); + } + + protected void update() throws IOException, NoSuchProjectException { + try { + repo = repoManager.openRepository(project); + try { + initUpdate(); + handleResult(runUpdate()); + } catch (IOException err) { + log.error("RefUpdate failed: branch not updated: " + branch.get(), err); + throw err; + } finally { + repo.close(); + repo = null; + } + } catch (RepositoryNotFoundException e) { + throw new NoSuchProjectException(project); + } + } + + protected void initUpdate() throws IOException { + update = repo.updateRef(branch.get()); + update.setExpectedOldObjectId(args.expectedOldObjectId); + update.setNewObjectId(args.newObjectId); + update.setRefLogIdent(args.refLogIdent); + update.setForceUpdate(args.isForceUpdate); + if (args.refLogMessage != null) { + update.setRefLogMessage(args.refLogMessage, true); + } + } + + protected RefUpdate.Result runUpdate() throws IOException { + if (delete) { + return update.delete(); + } + return update.update(); + } + + protected void handleResult(RefUpdate.Result result) throws IOException { + switch (result) { + case FORCED: + if (!delete && !args.isForceUpdate) { + throw new IOException(result.name()); + } + case FAST_FORWARD: + case NEW: + case NO_CHANGE: + onUpdated(update, args); + break; + default: + throw new IOException(result.name()); + } + } + + protected void onUpdated(RefUpdate update, Args args) { + if (update.getResult() == RefUpdate.Result.FAST_FORWARD) { + tagCache.updateFastForward( + project, update.getName(), update.getOldObjectId(), args.newObjectId); + } + if (userProvider.get().isIdentifiedUser()) { + AccountState accountState = accountCache.get(userProvider.get().getAccountId()); + gitRefUpdated.fire(project, update, accountState.getAccount()); + } + } + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/Batch.java b/src/main/java/com/googlesource/gerrit/plugins/batch/Batch.java new file mode 100644 index 0000000..ff34662 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/Batch.java
@@ -0,0 +1,104 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch; + +import com.google.gerrit.reviewdb.client.Account; +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.PatchSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +/** Data class (for serialization) to represent the contents/state of a batch */ +public class Batch { + public enum State { + OPEN, + CLOSED; + } + + public static class Change { + int number; + int patchSet; + + Change(PatchSet.Id psId) { + number = psId.getParentKey().get(); + patchSet = psId.get(); + } + } + + public class Destination { + public String project; + public String ref; + public String sha1; + public String downloadRef; + public List<Change> changes; + + public void add(PatchSet.Id psId) { + if (changes == null) { + changes = new ArrayList<Change>(); + } + changes.add(new Batch.Change(psId)); + } + } + + public final String id; + public Integer version; + public Account.Id owner; + public State state; + public List<Destination> destinations; + public Date lastModified; + + public Batch(Account.Id owner) { + this.id = UUID.randomUUID().toString(); + this.version = 0; + this.state = State.OPEN; + this.owner = owner; + } + + /** Get a non-null list without modifying the batch. */ + public List<Destination> listDestinations() { + if (destinations == null) { + return Collections.emptyList(); + } + return destinations; + } + + public Destination getDestination(Branch.NameKey branch) { + if (destinations == null) { + destinations = new ArrayList<Destination>(); + } + Destination dest = getExistingDestination(branch); + if (dest == null) { + dest = new Destination(); + dest.project = branch.getParentKey().get(); + dest.ref = branch.get(); + destinations.add(dest); + } + return dest; + } + + protected Destination getExistingDestination(Branch.NameKey branch) { + if (destinations == null) { + destinations = new ArrayList<Destination>(); + } + for (Destination dest : destinations) { + if (dest.project.equals(branch.getParentKey().get()) && dest.ref.equals(branch.get())) { + return dest; + } + } + return null; + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/BatchCloser.java b/src/main/java/com/googlesource/gerrit/plugins/batch/BatchCloser.java new file mode 100644 index 0000000..5f68d7c --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/BatchCloser.java
@@ -0,0 +1,67 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch; + +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.gerrit.server.util.RefUpdater; +import com.google.inject.Inject; +import java.io.IOException; +import org.eclipse.jgit.lib.ObjectId; + +public class BatchCloser { + protected final RefUpdater refUpdater; + protected final IdentifiedUser user; + protected final BatchStore store; + + @Inject + BatchCloser(RefUpdater refUpdater, IdentifiedUser user, BatchStore store) { + this.refUpdater = refUpdater; + this.user = user; + this.store = store; + } + + public void close(Batch batch) throws IOException, IllegalStateException, NoSuchProjectException { + if (batch.state != Batch.State.OPEN) { + throw new IllegalStateException( + "Invalid Operation for Batch(" + batch.id + "): " + batch.state.toString()); + } + createDownloadRefs(batch); + batch.state = Batch.State.CLOSED; + store.save(batch); + } + + protected void createDownloadRefs(Batch batch) throws IOException, NoSuchProjectException { + for (Batch.Destination dest : batch.listDestinations()) { + dest.downloadRef = getBatchRef(batch, dest); + Project.NameKey project = new Project.NameKey(dest.project); + Branch.NameKey branch = new Branch.NameKey(project, dest.downloadRef); + ObjectId id = ObjectId.fromString(dest.sha1); + refUpdater.update(branch, ObjectId.zeroId(), id); + } + } + + protected String getBatchRef(Batch batch, Batch.Destination dest) { + // AccountId is always present, UserName is optional but the preferred identifier + if (user.getUserName() != null) { + return String.format( + "refs/batch/%s/%s/%s/%s", "users", user.getUserName(), batch.id, dest.ref); + } + return String.format( + "refs/batch/%s/%s/%s/%s", + "accounts", String.valueOf(user.getAccountId().get()), batch.id, dest.ref); + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/BatchStore.java b/src/main/java/com/googlesource/gerrit/plugins/batch/BatchStore.java new file mode 100644 index 0000000..92e52d8 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/BatchStore.java
@@ -0,0 +1,89 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch; + +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.File; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.server.config.AllProjectsName; +import com.google.gerrit.server.git.meta.GitFile; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.gerrit.server.util.RefUpdater; +import com.google.gson.Gson; +import com.google.inject.Inject; +import com.googlesource.gerrit.plugins.batch.exception.NoSuchBatchException; +import java.io.IOException; +import java.util.ConcurrentModificationException; +import java.util.Date; +import javax.inject.Singleton; +import org.eclipse.jgit.errors.ConfigInvalidException; + +@Singleton +public class BatchStore { + public static final String BATCHES_REF = "refs/meta/batch/batches/"; + public static final String FILE_NAME = "batch.json"; + + protected final Project.NameKey project; + protected final GitFile.Factory gitFileFactory; + protected final RefUpdater refUpdater; + protected final Gson gson = new Gson(); + + @Inject + public BatchStore(AllProjectsName project, GitFile.Factory gitFileFactory, RefUpdater refUpdater) + throws IOException { + this.project = project; + this.gitFileFactory = gitFileFactory; + this.refUpdater = refUpdater; + } + + public void save(Batch batch) throws IOException, NoSuchProjectException { + if (batch.version != 0) { + throw new ConcurrentModificationException(); + } + batch.version++; + batch.lastModified = new Date(); + try { + gitFileFactory + .create(getFileNameKey(batch.id)) + .write(gson.toJson(batch), "Batch created (batch plugin)"); + } catch (ConfigInvalidException e) { // Not real, never going to be thrown + throw new RuntimeException(e); + } + } + + public Batch read(String id) throws IOException, NoSuchBatchException { + try { + Batch batch = gson.fromJson(gitFileFactory.create(getFileNameKey(id)).read(), Batch.class); + if (batch != null) { // gson.fromJson() can return null without throwing an exception + return batch; + } + } catch (ConfigInvalidException e) { // Not real, never going to be thrown + throw new RuntimeException(e); + } catch (NoSuchProjectException e) { + } + throw new NoSuchBatchException(id); + } + + protected File.NameKey getFileNameKey(String id) { + return getFileNameKey(getBranch(id)); + } + + protected Branch.NameKey getBranch(String id) { + return new Branch.NameKey(project, BATCHES_REF + id); + } + + protected File.NameKey getFileNameKey(Branch.NameKey branch) { + return new File.NameKey(branch, FILE_NAME); + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/Module.java b/src/main/java/com/googlesource/gerrit/plugins/batch/Module.java new file mode 100644 index 0000000..cfb2c15 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/Module.java
@@ -0,0 +1,26 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch; + +import com.google.gerrit.extensions.config.FactoryModule; +import com.googlesource.gerrit.plugins.batch.util.MergeBranch; +import com.googlesource.gerrit.plugins.batch.util.MergeBuilder; + +public class Module extends FactoryModule { + @Override + protected void configure() { + factory(MergeBranch.Factory.class); + factory(MergeBuilder.Factory.class); + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/cli/FastForwardOptions.java b/src/main/java/com/googlesource/gerrit/plugins/batch/cli/FastForwardOptions.java new file mode 100644 index 0000000..3c8c5f9 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/cli/FastForwardOptions.java
@@ -0,0 +1,71 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch.cli; + +import com.google.gerrit.sshd.BaseCommand.UnloggedFailure; +import com.googlesource.gerrit.plugins.batch.util.MergeBuilder.FastForwardMode; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import org.eclipse.jgit.util.StringUtils; +import org.kohsuke.args4j.Option; + +public class FastForwardOptions { + @Option(name = "--ff", usage = "fast forward update if possible (default)") + protected boolean ff; + + @Option(name = "--no-ff", usage = "create a merge commit even for a fast forward") + protected boolean noff; + + @Option( + name = "--ff-only", + usage = "abort unless the merge is a fast forward or branch is already up-to-date") + protected boolean ffOnly; + + protected EnumSet<FastForwardMode> selected; + + public FastForwardMode getFastForwardMode() throws UnloggedFailure { + if (selected == null) { + EnumMap<FastForwardMode, Boolean> valuesByMode = + new EnumMap<FastForwardMode, Boolean>(FastForwardMode.class); + valuesByMode.put(FastForwardMode.FF, ff); + valuesByMode.put(FastForwardMode.NO_FF, noff); + valuesByMode.put(FastForwardMode.FF_ONLY, ffOnly); + + selected = EnumSet.noneOf(FastForwardMode.class); + for (FastForwardMode mode : valuesByMode.keySet()) { + if (valuesByMode.get(mode)) { + selected.add(mode); + } + } + } + if (selected.size() > 1) { + throw new UnloggedFailure( + 1, StringUtils.join(toStrings(selected), ", ") + " are mutually exclusive"); + } + if (selected.size() == 1) { + return selected.toArray(new FastForwardMode[1])[0]; + } + return null; + } + + protected static Set<String> toStrings(EnumSet<FastForwardMode> modes) { + Set<String> out = new HashSet<String>(); + for (FastForwardMode mode : modes) { + out.add(mode.getName()); + } + return out; + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/cli/MergeStrategyOption.java b/src/main/java/com/googlesource/gerrit/plugins/batch/cli/MergeStrategyOption.java new file mode 100644 index 0000000..03c4545 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/cli/MergeStrategyOption.java
@@ -0,0 +1,41 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch.cli; + +import com.google.gerrit.sshd.BaseCommand.UnloggedFailure; +import org.eclipse.jgit.merge.MergeStrategy; +import org.kohsuke.args4j.Option; + +public class MergeStrategyOption { + @Option( + name = "--strategy", + metaVar = "STRATEGY", + usage = "jgit merge strategy(ours|theirs|simple[-two-way-in-core]|resolve) to use") + protected String strategy; + + protected MergeStrategy mergeStrategy; + + public MergeStrategy getMergeStrategy() throws UnloggedFailure { + if (mergeStrategy == null && strategy != null) { + if ("simple".equals(strategy)) { + strategy = "simple-two-way-in-core"; + } + mergeStrategy = MergeStrategy.get(strategy); + if (mergeStrategy == null) { + throw new UnloggedFailure(1, "unknown strategy " + strategy); + } + } + return mergeStrategy; + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/cli/PatchSetArgument.java b/src/main/java/com/googlesource/gerrit/plugins/batch/cli/PatchSetArgument.java new file mode 100644 index 0000000..d20a226 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/cli/PatchSetArgument.java
@@ -0,0 +1,106 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch.cli; + +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.PatchSet; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.PatchSetUtil; +import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.server.permissions.ChangePermission; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.gerrit.sshd.BaseCommand.UnloggedFailure; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; + +public class PatchSetArgument { + public static class Factory { + protected final PermissionBackend permissionBackend; + protected final ChangeNotes.Factory notesFactory; + protected final ReviewDb reviewDb; + protected final PatchSetUtil psUtil; + protected final CurrentUser user; + + @Inject + protected Factory( + ChangeNotes.Factory notesFactory, + PermissionBackend permissionBackend, + ReviewDb reviewDb, + PatchSetUtil psUtil, + CurrentUser user) { + this.notesFactory = notesFactory; + this.permissionBackend = permissionBackend; + this.reviewDb = reviewDb; + this.psUtil = psUtil; + this.user = user; + } + + public PatchSetArgument createForArgument(String token) { + try { + PatchSet.Id patchSetId = parsePatchSet(token); + ChangeNotes changeNotes = notesFactory.createChecked(patchSetId.getParentKey()); + permissionBackend + .user(user) + .database(reviewDb) + .change(changeNotes) + .check(ChangePermission.READ); + return new PatchSetArgument( + changeNotes.getChange(), psUtil.get(reviewDb, changeNotes, patchSetId)); + } catch (PermissionBackendException | AuthException e) { + throw new IllegalArgumentException("database error", e); + } catch (UnloggedFailure e) { + throw new IllegalArgumentException(e.getMessage(), e); + } catch (OrmException e) { + throw new IllegalArgumentException("database error", e); + } + } + + protected PatchSet.Id parsePatchSet(String patchIdentity) throws UnloggedFailure, OrmException { + // By older style change,patchset + if (patchIdentity.matches("^[1-9][0-9]*,[1-9][0-9]*$")) { + try { + return PatchSet.Id.parse(patchIdentity); + } catch (IllegalArgumentException e) { + throw error("\"" + patchIdentity + "\" is not a valid patch set"); + } + } + throw error("\"" + patchIdentity + "\" is not a valid patch set"); + } + + protected String noSuchPatchSet(String patchIdentity) { + return "\"" + patchIdentity + "\" no such patch set"; + } + + protected static UnloggedFailure error(final String msg) { + return new UnloggedFailure(1, msg); + } + } + + public final PatchSet patchSet; + public final Change change; + + public PatchSetArgument(Change change, PatchSet patchSet) { + this.patchSet = patchSet; + this.change = change; + } + + public void ensureLatest() { + if (!change.currentPatchSetId().equals(patchSet.getId())) { + throw new IllegalArgumentException(patchSet + " is not the latest patch set"); + } + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/exception/NoSuchBatchException.java b/src/main/java/com/googlesource/gerrit/plugins/batch/exception/NoSuchBatchException.java new file mode 100644 index 0000000..babe9db --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/exception/NoSuchBatchException.java
@@ -0,0 +1,21 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.batch.exception; + +public class NoSuchBatchException extends Exception { + public NoSuchBatchException(String id) { + super("No such batch: " + id); + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/MergeChangeCommand.java b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/MergeChangeCommand.java new file mode 100644 index 0000000..b25e110 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/MergeChangeCommand.java
@@ -0,0 +1,302 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch.ssh; + +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.PatchSet; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.OutputFormat; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.project.NoSuchRefException; +import com.google.gerrit.sshd.CommandMetaData; +import com.google.gerrit.sshd.SshCommand; +import com.google.gerrit.util.cli.Options; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.googlesource.gerrit.plugins.batch.Batch; +import com.googlesource.gerrit.plugins.batch.BatchCloser; +import com.googlesource.gerrit.plugins.batch.cli.FastForwardOptions; +import com.googlesource.gerrit.plugins.batch.cli.MergeStrategyOption; +import com.googlesource.gerrit.plugins.batch.cli.PatchSetArgument; +import com.googlesource.gerrit.plugins.batch.util.MergeBranch; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.kohsuke.args4j.Argument; +import org.kohsuke.args4j.Option; + +@CommandMetaData( + name = "merge-change", + description = "Merge changes in the git repository to a batch") +public class MergeChangeCommand extends SshCommand { + @Inject @Options public MergeStrategyOption strategy; + @Inject @Options public FastForwardOptions fastForward; + + @Option( + name = "--message", + aliases = "-m", + metaVar = "MESSAGE", + usage = "commit message to use when applying a change") + public String message; + + @Option(name = "--close", usage = "close batch on merge success") + public boolean close; + + protected LinkedHashMap<PatchSet.Id, PatchSetArgument> patchSetArgumentsByPatchSet = + new LinkedHashMap<PatchSet.Id, PatchSetArgument>(); + + @Argument( + index = 0, + required = true, + multiValued = true, + metaVar = "{CHANGE,PATCHSET}", + usage = "list of patch sets to merge") + protected void addPatchSetId(final String token) { + PatchSetArgument psa = patchSetArgumentFactory.createForArgument(token); + psa.ensureLatest(); + patchSetArgumentsByPatchSet.put(psa.patchSet.getId(), psa); + } + + @Inject protected PatchSetArgument.Factory patchSetArgumentFactory; + @Inject protected MergeBranch.Factory mergeBranchFactory; + @Inject protected BatchCloser batchCloser; + @Inject protected ReviewDb db; + @Inject protected IdentifiedUser user; + @Inject protected GitRepositoryManager repoManager; + protected Map<PatchSet.Id, List<ObjectId>> parentsByPsarg = + new HashMap<PatchSet.Id, List<ObjectId>>(); + + @Override + public void run() throws Exception { + parseCommandLine(); + Batch batch = new Batch(user.getAccountId()); + String err = null; + try { + Resolver resolver = new Resolver(patchSetArgumentsByPatchSet.values()); + for (PatchSetArgument psarg : resolver.resolved) { + err = "Couldn't merge change(" + psarg.patchSet + ") to batch(" + batch.id + ")"; + merge(batch, psarg.change, psarg.patchSet); + } + if (close) { + err = "Could not close batch(" + batch.id + ")"; + batchCloser.close(batch); + } + } catch (Exception e) { + String msg = e.getMessage(); + if (msg != null) { + err += ": " + msg; + } + throw die(err); + } + batch.version = null; + out.write((OutputFormat.JSON.newGson().toJson(batch) + "\n").getBytes(ENC)); + out.flush(); + } + + protected boolean isParentMergedInto(PatchSetArgument psarg, Iterable<ObjectId> sha1s) + throws IOException, OrmException, RepositoryNotFoundException { + for (ObjectId sha1 : sha1s) { + if (isParentMergedInto(psarg, sha1)) { + return true; + } + } + return false; + } + + protected boolean isParentMergedInto(PatchSetArgument psarg, ObjectId sha1) + throws IOException, OrmException, RepositoryNotFoundException { + List<ObjectId> parents = getParents(psarg); + if (parents.isEmpty()) { + return true; + } + for (ObjectId parent : parents) { + Project.NameKey project = psarg.change.getDest().getParentKey(); + Boolean isMergedInto = isMergedInto(project, parent, sha1); + if (isMergedInto == null) { + throw new IOException(); + } + if (isMergedInto) { + return true; + } + } + return false; + } + + public boolean isMergedInto(Project.NameKey project, ObjectId needle, ObjectId haystack) + throws IOException { + try (Repository repo = repoManager.openRepository(project); + RevWalk walk = new RevWalk(repo)) { + return walk.isMergedInto(walk.parseCommit(needle), walk.parseCommit(haystack)); + } + } + + protected ObjectId getTip(Branch.NameKey branch) + throws IOException, NoSuchRefException, RepositoryNotFoundException { + try (Repository repo = repoManager.openRepository(branch.getParentKey())) { + Ref ref = repo.getRefDatabase().getRef(branch.get()); + if (ref == null) { + throw new NoSuchRefException(branch.toString()); + } + return ref.getObjectId(); + } + } + + protected void merge(Batch batch, Change change, PatchSet ps) + throws Exception, IOException, NoSuchRefException, OrmException, UnloggedFailure { + Branch.NameKey branch = change.getDest(); + Batch.Destination dest = batch.getDestination(branch); + dest.sha1 = + mergeBranchFactory + .create( + branch, + dest.sha1, + ps.getRefName(), + strategy.getMergeStrategy(), + fastForward.getFastForwardMode(), + message) + .call() + .getName(); + dest.add(ps.getId()); + } + + /* A Resolver which ensures that changes are eligible to merge before + * resolving them. Once resolved, changes are ordered to minimize the + * amount of merge commits required to merge them. + */ + protected class Resolver { + protected class ParentsNotOnBranchException extends Exception { + protected ParentsNotOnBranchException(PatchSetArgument psarg) { + super( + "No Parent of " + + psarg.patchSet + + " is on its destination branch(" + + psarg.change.getDest() + + ")"); + } + } + + protected class Destination { + List<PatchSetArgument> remaining = new ArrayList<PatchSetArgument>(); + Set<ObjectId> sources = new HashSet<ObjectId>(); + + Destination(Branch.NameKey branch) throws IOException, NoSuchRefException { + sources.add(getTip(branch)); + } + } + + protected Map<Branch.NameKey, Destination> destinationsByBranches = + new HashMap<Branch.NameKey, Destination>(); + protected List<PatchSetArgument> resolved = new ArrayList<PatchSetArgument>(); + + protected Resolver(Iterable<PatchSetArgument> psargs) + throws Exception, IOException, OrmException, NoSuchRefException, + RepositoryNotFoundException { + add(psargs); + while (resolve()) {} + for (Destination dest : destinationsByBranches.values()) { + if (!dest.remaining.isEmpty()) { + throw new ParentsNotOnBranchException(dest.remaining.get(0)); + } + } + Collections.reverse(resolved); // Reduces merges + } + + protected boolean resolve() + throws IOException, OrmException, NoSuchRefException, RepositoryNotFoundException { + boolean found = false; + for (Destination dest : destinationsByBranches.values()) { + // If more dependencies are destined for the same branch than not, + // then resolving a branch as much as possible will reduce the + // total iterations required. + while (resolve(dest)) { + found = true; + } + } + return found; + } + + protected boolean resolve(Destination dest) + throws IOException, OrmException, NoSuchRefException, RepositoryNotFoundException { + boolean found = false; + for (PatchSetArgument psarg : dest.remaining) { + if (isParentMergedInto(psarg, dest.sources)) { + found = true; + resolved.add(psarg); + dest.sources.add(ObjectId.fromString(psarg.patchSet.getRevision().get())); + } + } + dest.remaining.removeAll(resolved); + return found; + } + + protected void add(Iterable<PatchSetArgument> psargs) throws IOException, NoSuchRefException { + for (PatchSetArgument psarg : psargs) { + add(psarg); + } + } + + protected void add(PatchSetArgument psarg) throws IOException, NoSuchRefException { + Branch.NameKey branch = psarg.change.getDest(); + Destination dest = getDestination(branch); + dest.remaining.add(psarg); + } + + protected Destination getDestination(Branch.NameKey b) throws IOException, NoSuchRefException { + Destination dest = destinationsByBranches.get(b); + if (dest == null) { + dest = new Destination(b); + destinationsByBranches.put(b, dest); + } + return dest; + } + } + + protected List<ObjectId> getParents(PatchSetArgument psarg) throws IOException { + PatchSet.Id id = psarg.patchSet.getId(); + List<ObjectId> parents = parentsByPsarg.get(id); + if (parents == null) { + parents = loadParents(psarg); + parentsByPsarg.put(id, parents); + } + return parents; + } + + protected List<ObjectId> loadParents(PatchSetArgument psarg) throws IOException { + try (Repository repo = repoManager.openRepository(psarg.change.getProject()); + RevWalk revWalk = new RevWalk(repo)) { + List<ObjectId> parents = new ArrayList<ObjectId>(); + ObjectId id = ObjectId.fromString(psarg.patchSet.getRevision().get()); + RevCommit c = revWalk.parseCommit(id); + for (RevCommit parent : c.getParents()) { + parents.add(parent); + } + return parents; + } + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SshModule.java new file mode 100644 index 0000000..0c84155 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SshModule.java
@@ -0,0 +1,27 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch.ssh; + +import com.google.gerrit.server.git.meta.GitFile; +import com.google.gerrit.sshd.PluginCommandModule; +import com.google.inject.assistedinject.FactoryModuleBuilder; + +public class SshModule extends PluginCommandModule { + @Override + protected void configureCommands() { + install(new FactoryModuleBuilder().build(GitFile.Factory.class)); + + command(MergeChangeCommand.class); + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/util/MergeBranch.java b/src/main/java/com/googlesource/gerrit/plugins/batch/util/MergeBranch.java new file mode 100644 index 0000000..7ef6cdb --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/util/MergeBranch.java
@@ -0,0 +1,132 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch.util; + +import com.google.gerrit.common.Nullable; +import com.google.gerrit.extensions.client.InheritableBoolean; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.reviewdb.client.RefNames; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.git.IntegrationException; +import com.google.gerrit.server.project.NoSuchRefException; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.server.project.ProjectState; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.googlesource.gerrit.plugins.batch.util.MergeBuilder.FastForwardMode; +import java.io.IOException; +import java.util.concurrent.Callable; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.MergeStrategy; + +public class MergeBranch implements Callable<ObjectId> { + public interface Factory { + MergeBranch create( + @Assisted Branch.NameKey destBranch, + @Assisted("destSha") @Nullable String destSha, + @Assisted("sourceRef") String srcName, + @Assisted MergeStrategy strategy, + @Assisted FastForwardMode fastForwardMode, + @Assisted("message") String message); + } + + protected final GitRepositoryManager repoManager; + protected final ProjectCache projectCache; + protected final MergeBuilder.Factory builderFactory; + protected final Project.NameKey projectName; + protected final String destName; + protected ObjectId destId; + protected final String srcName; + protected final String message; + protected MergeStrategy strategy; + protected FastForwardMode fastForwardMode = FastForwardMode.FF; + + @Inject + MergeBranch( + GitRepositoryManager repoManager, + ProjectCache projectCache, + MergeBuilder.Factory builderFactory, + @Assisted Branch.NameKey destBranch, + @Assisted("destSha") @Nullable String destSha, + @Assisted("sourceRef") String srcName, + @Assisted("message") @Nullable String message, + @Assisted @Nullable MergeStrategy strategy, + @Assisted @Nullable FastForwardMode fastForwardMode) { + this.projectCache = projectCache; + this.repoManager = repoManager; + this.builderFactory = builderFactory; + this.projectName = destBranch.getParentKey(); + destName = RefNames.fullName(destBranch.get()); + if (destSha != null) { + this.destId = ObjectId.fromString(destSha); + } + this.srcName = srcName; + this.message = message; + this.strategy = strategy; + if (fastForwardMode != null) { + this.fastForwardMode = fastForwardMode; + } + } + + @Override + public ObjectId call() + throws IOException, NoSuchRefException, RepositoryNotFoundException, IntegrationException, + BadRequestException { + try (Repository repo = repoManager.openRepository(projectName)) { + Ref destRef = repo.getRefDatabase().getRef(destName); + if (destRef == null) { + throw new NoSuchRefException(destName); + } + if (destId == null) { + destId = repo.resolve(destName); + if (destId == null) { + throw new BadRequestException("Invalid Revision"); + } + } + ObjectId srcId = repo.resolve(srcName); + if (srcId == null) { + throw new BadRequestException("Invalid Revision"); + } + strategy = defaultStrategy(strategy); + return builderFactory + .create(projectName, message, strategy, fastForwardMode, destId, srcId) + .call(); + } + } + + protected MergeStrategy defaultStrategy(MergeStrategy strategy) { + if (strategy == null) { + Project project = projectFromName(projectName); + if (project != null && project.getUseContentMerge() == InheritableBoolean.TRUE) { + return MergeStrategy.RESOLVE; + } else { + return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE; + } + } + return strategy; + } + + protected Project projectFromName(Project.NameKey name) { + ProjectState ps = projectCache.get(name); + if (ps == null) { + return null; + } + return ps.getProject(); + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/util/MergeBuilder.java b/src/main/java/com/googlesource/gerrit/plugins/batch/util/MergeBuilder.java new file mode 100644 index 0000000..28e583f --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/batch/util/MergeBuilder.java
@@ -0,0 +1,196 @@ +// Copyright (C) 2014 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.googlesource.gerrit.plugins.batch.util; + +import com.google.gerrit.common.Nullable; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.server.GerritPersonIdent; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.git.IntegrationException; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import java.io.IOException; +import java.sql.Timestamp; +import java.util.concurrent.Callable; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.merge.Merger; +import org.eclipse.jgit.merge.ThreeWayMerger; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.util.StringUtils; + +public class MergeBuilder implements Callable<ObjectId> { + /** + * The modes available for fast forward merges corresponding to the --ff, --no-ff and --ff-only + * options + */ + public static enum FastForwardMode { + /** + * Corresponds to the default --ff option (for a fast forward update the branch pointer only). + */ + FF("ff"), + /** Corresponds to the --no-ff option (create a merge commit even for a fast forward). */ + NO_FF("no-ff"), + /** + * Corresponds to the --ff-only option (abort unless the merge is a fast forward or branch is + * already up-to-date). + */ + FF_ONLY("ff-only"); + public final String name; + + private FastForwardMode(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public static FastForwardMode fromString(String str) { + if (StringUtils.isEmptyOrNull(str)) { + return null; + } + for (FastForwardMode mode : FastForwardMode.values()) { + if (mode.getName().equalsIgnoreCase(str)) { + return mode; + } + } + return null; + } + } + + public interface Factory { + MergeBuilder create( + @Assisted Project.NameKey project, + @Assisted("message") @Nullable String message, + @Assisted @Nullable MergeStrategy strategy, + @Assisted @Nullable FastForwardMode fastForwardMode, + @Assisted("firstParent") ObjectId firstParent, + @Assisted("secondParent") ObjectId secondParent); + } + + protected final GitRepositoryManager repoManager; + protected final PersonIdent gerrit; + protected final IdentifiedUser user; + protected final Project.NameKey project; + protected String message; + protected final MergeStrategy strategy; + protected FastForwardMode fastForwardMode = FastForwardMode.FF; + protected final ObjectId firstParent; + protected final ObjectId secondParent; + + @Inject + MergeBuilder( + GitRepositoryManager repoManager, + @GerritPersonIdent PersonIdent gerrit, + IdentifiedUser user, + @Assisted Project.NameKey project, + @Assisted("message") @Nullable String message, + @Assisted @Nullable MergeStrategy strategy, + @Assisted @Nullable FastForwardMode fastForwardMode, + @Assisted("firstParent") ObjectId firstParent, + @Assisted("secondParent") ObjectId secondParent) { + this.repoManager = repoManager; + this.gerrit = gerrit; + this.user = user; + this.project = project; + this.message = message; + this.strategy = strategy; + if (fastForwardMode != null) { + this.fastForwardMode = fastForwardMode; + } + this.firstParent = firstParent; + this.secondParent = secondParent; + } + + @Override + public ObjectId call() throws IOException, IntegrationException { + try (Repository repo = repoManager.openRepository(project)) { + return build(repo); + } + } + + public ObjectId build(Repository repo) throws IOException, IntegrationException { + try (RevWalk revWalk = new RevWalk(repo)) { + RevCommit firstParentCommit = revWalk.lookupCommit(firstParent); + RevCommit secondParentCommit = revWalk.lookupCommit(secondParent); + if (revWalk.isMergedInto(secondParentCommit, firstParentCommit)) { + return firstParent; // already up to date + } + if (fastForwardMode != FastForwardMode.NO_FF + && revWalk.isMergedInto(firstParentCommit, secondParentCommit)) { + return secondParent; // Fast forward merge + } + if (fastForwardMode == FastForwardMode.FF_ONLY) { + throw new IntegrationException("Merge aborted"); // because not FF + } + return merge(repo, revWalk); + } + } + + protected ObjectId merge(Repository repo, RevWalk revWalk) + throws IOException, IntegrationException { + ThreeWayMerger merger = getMerger(repo); + if (!merger.merge(firstParent, secondParent)) { + throw new IntegrationException("Merge conflict"); + } + message = defaultMessage(revWalk, message); + return insert(merger, buildCommit(merger)); + } + + protected ThreeWayMerger getMerger(Repository repo) { + if (strategy == MergeStrategy.RESOLVE) { + return MergeStrategy.RESOLVE.newMerger(repo, true); + } else { + return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.newMerger(repo); + } + } + + protected CommitBuilder buildCommit(Merger merger) { + final CommitBuilder mergeCommit = new CommitBuilder(); + mergeCommit.setTreeId(merger.getResultTreeId()); + mergeCommit.setParentIds(firstParent, secondParent); + mergeCommit.setAuthor( + user.newCommitterIdent(new Timestamp(System.currentTimeMillis()), gerrit.getTimeZone())); + mergeCommit.setCommitter(gerrit); + mergeCommit.setMessage(message); + return mergeCommit; + } + + protected String defaultMessage(RevWalk walk, String message) { + if (message == null) { + try { + message = walk.parseCommit(secondParent).getShortMessage(); + } catch (Exception e) { + message = secondParent.getName(); + } + message = "Merge \"" + message + "\""; + } + return message; + } + + protected ObjectId insert(Merger merger, CommitBuilder commit) throws IOException { + try (ObjectInserter objInserter = merger.getObjectInserter()) { + ObjectId mergeCommitId = objInserter.insert(commit); + objInserter.flush(); + return mergeCommitId; + } + } +} diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md new file mode 100644 index 0000000..617f3c1 --- /dev/null +++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,91 @@ +The @PLUGIN@ plugin provides a mechanism for building and previewing +sets of proposed updates to multiple projects/refs that should be +applied in a batch. These updates are built with Gerrit changes. + +While a large focus of Gerrit changes is reviewing, the focus of +batch updates tend to be verification (by CI systems). Batch +updates are not reviewable in the Gerrit UI, but they are +downloadable as git refs. The @PLUGIN@ update service provides the +tools to build these refs by merging changes to temporary "snapshot" +refs, which can then be tested. The intent is to make the same exact +(same git SHA1s) updates testable across potentially many machines. + +Creating Batches +---------------- +A simple use case for a @PLUGIN@ update might look like this: +a CI systems wants to verify a build for changes 123 (patchset 3), +and 456 (patchset 7) at the same time. These changes are destined +for projectA/branchX, and projectB/branchY respectively. The CI +system (jenkins gerrit user) may start by opening a batch, +merging changes to it, and closing the batch, all in one simple +command like this: + +``` +$ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ merge-change 123,3 456,7 --close + { + "destinations": [ + { + "changes": [ + { + "number": 123, + "patch_set": 3 + } + ], + "download_ref": "refs/batch/users/jenkins/0644a132-5b79-4c88-bf22-9364a1d02deb/refs/heads/branchX", + "project": "projectA", + "ref": "refs/heads/branchX", + "sha1": "00de3cf878b8bd51fa56aa9a8d5e8631ae71ad60" + }, + { + "changes": [ + { + "number": 456, + "patch_set": 7 + } + ], + "download_ref": "refs/batch/users/jenkins/0644a132-5b79-4c88-bf22-9364a1d02deb/refs/heads/branchY", + "project": "projectB", + "ref": "refs/heads/branchY", + "sha1": "bd26d343b99c25a0704d0ffe5c431900b1cf5c89" + } + ], + "id": "0644a132-5b79-4c88-bf22-9364a1d02deb", + "last_modified": "July 22, 2014 10:43:28 AM", + "owner": { + "id": 1000000 + }, + "state": "CLOSED" + } +``` + +Downloading Batches +------------------- +The CI system may then parse this json to get the refs to download the +batch updates from, download and test the batches. + +Download Ref Location +--------------------- +Download refs have two possible locations. By default the username will be +checked and if present it will be used, and the ref will be stored under the +refs/batch/users/* namespace. In the case that the username is not present +the account id will be used instead, and the ref will be stored under the +refs/batch/accounts/* namespace. + +Username present: +`refs/batch/users/{username}/...` + +Username not present: +`refs/batch/accounts/{account id}/...` + +The format above may be counted on and should be used to set read access +permissons on. The format of the download_ref after the "..."s is internal +and should not be counted on to be stable, use the download_ref field to +access the batch data instead of guessing at the format of this ref. + +Batch Storage +------------- +In order to maintain state about which changes are in a batch, and where the +download refs are stored for each batch, the batch data is stored as json on +the special refs/meta/batch/<batch_id> ref in the All-Projects project. This +is internal meta data to the batch plugin and these refs should not be +accessed or altered by users directly. diff --git a/src/main/resources/Documentation/cmd-merge-change.md b/src/main/resources/Documentation/cmd-merge-change.md new file mode 100644 index 0000000..08c20ab --- /dev/null +++ b/src/main/resources/Documentation/cmd-merge-change.md
@@ -0,0 +1,99 @@ +@PLUGIN@ merge-change +===================== + +NAME +---- +@PLUGIN@ merge-change - Merge changes to a batch. + +SYNOPSIS +-------- +``` +ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ merge-change <CHANGE,PATCHSET> ... + [--strategy {ours|theirs|simple[-two-way-in-core]|resolve}] + [--message <message>] [--ff | --no-ff | --ff-only] [--close] +``` + +DESCRIPTION +----------- +Merges the change(s) to the batch branch in the project and prints +the json with the new commit-id of the propsed branch update. + +ACCESS +------ +Caller must have read permission on a change to merge it to a batch. + +SCRIPTING +--------- +This command is intended to be used in scripts. + +OPTIONS +------- + +--strategy + + Use the given merge strategy. Usable strategies are "ours", "theirs", + "simple", "simple-two-way-in-core", and "resolve". + +--message + +-m + + Commit message to use when merging commit <COMMIT|REF>. + +--ff + + Fast forward update if possible. + +--no-ff + + Create a merge commit even for a fast forward merge. + +--ff-only + + Abort unless the merge is a fast forward or branch is + already up-to-date. + +--close + + Close the batch on successfull merge. Closing the batch + will persist it. + + +Notes: + +--ff, --no-ff, and --ff-only are mutually exclusive options and +--ff is assumed by default. + +The default merge strategy is based on the project config for the +destination ref. If the project config is set to "use content merge", +then it will be "resolve", else it will be "simple-two-way-in-core". + +EXAMPLES +-------- + +Merge a change to a batch: + +``` +$ ssh -p 29418 review.example.com batch merge 123,3 --close + { + "id": "0644a132-5b79-4c88-bf22-9364a1d02deb", + "owner": { + "id": 1000000 + }, + "state": "CLOSED", + "destinations": [ + { + "project": "projectA", + "ref": "refs/heads/branchX", + "sha1": "00de3cf878b8bd51fa56aa9a8d5e8631ae71ad60", + "download_ref": "refs/batch/users/jenkins/0644a132-5b79-4c88-bf22-9364a1d02deb/refs/heads/branchX", + "changes": [ + { + "number": 123, + "patch_set": 3 + } + ] + } + ] + } +``` diff --git a/test/test_batch_merge.sh b/test/test_batch_merge.sh new file mode 100755 index 0000000..17bdd49 --- /dev/null +++ b/test/test_batch_merge.sh
@@ -0,0 +1,238 @@ +#!/bin/bash + +# ---- JSON PARSING ---- +json_val_by() { # json index|'key' > value + echo "$1" | python -c "import json,sys;print json.load(sys.stdin)[$2]" +} +json_jval_by() { # json index|'key' > json_value + echo "$1" |\ + python -c "import json,sys;print json.dumps(json.load(sys.stdin)[$2])" +} +json_val_by_key() { json_val_by "$1" "'$2'" ; } # json key > value +json_jval_by_key() { json_jval_by "$1" "'$2'" ; } # json key > json_value + +# ---- TEST RESULTS ---- +result() { # test [error_message] + local result=$? + if [ $result -eq 0 ] ; then + echo "PASSED - $1 test" + else + echo "*** FAILED *** - $1 test" + RESULT=$result + [ $# -gt 1 ] && echo "$2" + fi +} + +# output must match expected to pass +result_out() { # test expected output + local disp=$(echo "Expected Output:" ;\ + echo " $2" ;\ + echo "Actual Output:" ;\ + echo " $3") + + [ "$2" = "$3" ] + result "$1" "$disp" +} + +# ---- Low level execution helpers ---- +q() { "$@" > /dev/null 2>&1 ; } # cmd [args...] # quiet a command +gssh() { ssh -p 29418 -x "$SERVER" "$@" 2>&1 ; } # run a gerrit ssh command +batchssh() { # run a batch ssh command + local out rtn + out=$(gssh "$PLUGIN" "$@") ; rtn=$? ; echo "$out" + [ -n "$VERBOSE" ] && echo "$out" >&2 + return $rtn +} +query() { gssh gerrit query "$@" ; } +remote_show() { # remote_ref > sha + git ls-remote "$GITURL" 2>/dev/null | awk '$2 == "'"$1"'" {print $1}' +} +mygit() { git --work-tree="$REPO_DIR" --git-dir="$GIT_DIR" "$@" ; } # [args...] + +# ---- Custom batch getters ----- +b_id() { json_val_by_key "$bjson" id ; } # > batch_id +b_state() { json_val_by_key "$bjson" state ; } # > batch_state +b_destination() { # index # > batch_destination[index] + json_jval_by "$(json_jval_by_key "$bjson" destinations)" "$1" +} + +# ---- Custom batch destination getters ----- +d_ref() { json_val_by_key "$1" ref ; } # destination > ref +d_project() { json_val_by_key "$1" project ; } # destination > project +d_sha1() { json_val_by_key "$1" sha1 ; } # destination > sha1 +d_download() { json_val_by_key "$1" download_ref ; } # destination > download_ref + +# ---- Parsers ---- +query_by() { echo "$1" | awk '/^ *'"$2"':/{print $2}' ; } # qchange key > val + +get_change_num() { # < gerrit_push_response > changenum + local url=$(awk '/New Changes:/ { getline; print $2 }') + echo "${url##*\/}" | tr -d -c '[:digit:]' +} + +nn() { # change_num > nn + local nn=$(($1 % 100)) + [ "$nn" -lt 10 ] && nn=0$nn + echo "$nn" +} + +#---- +get_ref_parents() { # ref > p1 p2 + q mygit fetch "$GITURL" "$1" || exit 1 + mygit rev-list -1 FETCH_HEAD --parents | cut -d' ' -f2,3 +} + +create_change() { # [--dependent] branch file [file_content] > changenum + local opt_d + [ "$1" = "--dependent" ] && { opt_d=$1 ; shift ; } + local branch=$1 tmpfile=$2 content=$3 out rtn + [ -n "$content" ] || content=$RANDOM + + if [ -z "$opt_d" ] ; then + out=$(mygit fetch "$GITURL" "$branch" 2>&1) ||\ + cleanup "Failed to fetch $branch: $out" + out=$(mygit checkout FETCH_HEAD 2>&1) ||\ + cleanup "Failed to checkout $branch: $out" + fi + + echo -e "$content" > "$tmpfile" + + out=$(mygit add "$tmpfile" 2>&1) || cleanup "Failed to git add: $out" + + out=$(scp -p -P 29418 "$SERVER":hooks/commit-msg $HOOK_DIR 2>&1) || cleanup "Failed to fetch commit_msg hook: $out" + + out=$(mygit commit -m "Add $tmpfile" 2>&1) ||\ + cleanup "Failed to commit change: $out" + + out=$(mygit push "$GITURL" "HEAD:refs/for/$branch" 2>&1) ||\ + cleanup "Failed to push change: $out" + out=$(echo "$out" | get_change_num) ; rtn=$? ; echo "$out" + [ -n "$VERBOSE" ] && echo " change:$out" >&2 + return $rtn +} + +setupGroup() { # shortname longname + GROUP=$1 + echo + echo "$2" + echo "----------------------------------------------" +} + +cleanup() { # [error_message] + rm -rf "$REPO_DIR" + + if [ -n "$1" ] ; then + echo "$1, unable to perform batch tests" >&2 + exit 1 + fi +} + +usage() { # [error_message] + local prog=$(basename "$0") + + cat <<-EOF +Usage: $prog [-s|--server <server>] [-p|--project <project>] + [-r|--ref <ref branch>] [-g|--plugin <plugin>] [-h|--help] + + -h|--help usage/help + -g|--plugin <plugin> plugin to use for the test (default: batch) + -s|--server <server> server to use for the test (default: localhost) + -p|--project <project> git project to use (default: project0) + -r|--ref <ref branch> reference branch used to create branches (default: master) +EOF + + [ -n "$1" ] && echo -e '\n'"ERROR: $1" + exit 1 +} + +parseArgs() { + PLUGIN="batch" + SERVER="localhost" + PROJECT="tools/test/project0" + REF_BRANCH="master" + while (( "$#" )); do + case "$1" in + --plugin|-g) shift; PLUGIN=$1 ;; + --server|-s) shift; SERVER=$1 ;; + --project|-p) shift; PROJECT=$1 ;; + --ref|-r) shift; REF_BRANCH=$1 ;; + --help|-h) usage ;; + --verbose|-v) VERBOSE=$1 ;; + *) usage "invalid argument '$1'" ;; + esac + shift + done + + [ -n "$SERVER" ] || usage "server not set" + [ -n "$PROJECT" ] || usage "project not set" + [ -n "$REF_BRANCH" ] || usage "ref branch not set" +} + +parseArgs "$@" + +GITURL=ssh://$SERVER:29418/$PROJECT +git ls-remote --heads "$GITURL" >/dev/null || usage "invalid project/server" +DEST_REF=refs/heads/$REF_BRANCH + +REPO_DIR=$(mktemp -d) +trap cleanup EXIT +q git init "$REPO_DIR" + +GIT_DIR="$REPO_DIR/.git" +HOOK_DIR="$GIT_DIR/hooks" +FILE_A="$REPO_DIR/fileA" +FILE_B="$REPO_DIR/fileB" + +RESULT=0 + +setupGroup "merge-change" "Merge Change" # ------------- + +ch1=$(create_change "$REF_BRANCH" "$FILE_A") || exit +bjson=$(batchssh merge-change --close "$ch1",1) +result "$GROUP" "$bjson" + +id=$(b_id) +dest1=$(b_destination 0) +sha1=$(d_sha1 "$dest1") +qchange=$(query "$ch1" --current-patch-set) + +result_out "$GROUP project" "$PROJECT" "$(d_project "$dest1")" +result_out "$GROUP ref" "$DEST_REF" "$(d_ref "$dest1")" +result_out "$GROUP sha1" "$(query_by "$qchange" "revision")" "$sha1" +result_out "$GROUP state" "CLOSED" "$(b_state)" +result_out "$GROUP change_state" "NEW" "$(query_by "$qchange" "status")" + + +setupGroup "independent clean" "Independent changes, clean merge" # ------------ + +ch1=$(create_change "$REF_BRANCH" "$FILE_A") || exit +ch2=$(create_change "$REF_BRANCH" "$FILE_B") || exit +bjson=$(batchssh merge-change --close "$ch1",1 "$ch2",1) +result "$GROUP" "$bjson" + + +setupGroup "independent conflict" "Independent changes, merge conflict" # ------ + +ch1=$(create_change "$REF_BRANCH" "$FILE_A") || exit +ch2=$(create_change "$REF_BRANCH" "$FILE_A") || exit +bjson=$(batchssh merge-change --close "$ch1",1 "$ch2",1) +echo "$bjson"| grep -q "Couldn't merge change" +result "$GROUP" "$bjson" + + +setupGroup "dependent changes ff" "Dependent changes, fast forward" # --------- + +ch1=$(create_change "$REF_BRANCH" "$FILE_A") || exit +ch2=$(create_change --dependent "$REF_BRANCH" "$FILE_B") || exit +bjson=$(batchssh merge-change --close "$ch1",1 "$ch2",1) +id=$(b_id) +dest1=$(b_destination 0) +sha1=$(d_sha1 "$dest1") +qchange=$(query "$ch2" --current-patch-set) +result_out "$GROUP sha1" "$(query_by "$qchange" "revision")" "$sha1" +bjson=$(batchssh merge-change --close "$ch2",1 "$ch1",1) +result "$GROUP reverse" "$bjson" +! bjson=$(batchssh merge-change --close "$ch2",1) +result "$GROUP missing" "$bjson" + +exit $RESULT diff --git a/tools/bazel.rc b/tools/bazel.rc new file mode 100644 index 0000000..4ed16cf --- /dev/null +++ b/tools/bazel.rc
@@ -0,0 +1,2 @@ +build --workspace_status_command=./tools/workspace-status.sh +test --build_tests_only
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tools/bzl/BUILD
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl new file mode 100644 index 0000000..2b1df8c --- /dev/null +++ b/tools/bzl/plugin.bzl
@@ -0,0 +1,4 @@ +load( + "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl", + "gerrit_plugin", +)
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh new file mode 100755 index 0000000..6670f2c --- /dev/null +++ b/tools/workspace-status.sh
@@ -0,0 +1,17 @@ +#!/bin/bash + +# This script will be run by bazel when the build process starts to +# generate key-value information that represents the status of the +# workspace. The output should be like +# +# KEY1 VALUE1 +# KEY2 VALUE2 +# +# If the script exits with non-zero code, it's considered as a failure +# and the output will be discarded. + +function rev() { + git describe --always --match "v[0-9].*" --dirty +} +# TODO +echo STABLE_BUILD_BATCH_LABEL $(rev)